Skip to content

feat(go2): command the robot with calibrated velocity (SPORT Move) ov…#2567

Merged
spomichter merged 2 commits into
mainfrom
mustafa/go2-velocity-move
Jun 24, 2026
Merged

feat(go2): command the robot with calibrated velocity (SPORT Move) ov…#2567
spomichter merged 2 commits into
mainfrom
mustafa/go2-velocity-move

Conversation

@mustafab0

Copy link
Copy Markdown
Contributor

Problem

UnitreeWebRTCConnection.move() sent the body twist as raw normalized joystick stick deflections (lx/ly/rx) on WIRELESS_CONTROLLER, so the commanded wz/vx were never calibrated velocities

Solution

move() now sends SPORT Move (api_id 1008) with real m/s & rad/s (x forward, y left, z yaw CCW),

Contributor License Agreement

  • [ x] I have read and approved the CLA.

…er WebRTC

UnitreeWebRTCConnection.move() sent the body twist as raw normalized
joystick stick deflections (lx/ly/rx) on WIRELESS_CONTROLLER, so the
commanded wz/vx were never calibrated velocities — the firmware's
nonlinear, speed-coupled gait mixer was the de-facto plant.

move() now sends SPORT Move (api_id 1008) with real m/s & rad/s
(x forward, y left, z yaw CCW), fire-and-forget at tick rate, via a
shared _publish_move() helper that stop() also uses for a zero-velocity
halt. Gait stays the caller's responsibility (GO2Connection already runs
standup()/balance_stand() at start-up; Move works from BalanceStand).

On hardware this removed a ~2.2x yaw over-turn: 0.8 rad/s commanded went
from a 3.5s circle to ~10s (~224% -> ~78% of commanded yaw) — a clean
linear gain characterization can now fit. Re-characterize K/tau/L on this
command path.
@greptile-apps

greptile-apps Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Switches the Go2 movement command path from raw WIRELESS_CONTROLLER joystick deflections (lx/ly/rx) to the calibrated SPORT Move API (api_id 1008), so move() now accepts and sends real m/s and rad/s values rather than normalised stick positions.

  • Adds _publish_move(vx, vy, vyaw) which assembles the SPORT Move payload (header + JSON-stringified parameter, monotonic _move_seq id) and publishes it to rt/api/sport/request with msg_type=REQUEST.
  • Updates move() to forward twist.linear.x/y and twist.angular.z directly to _publish_move, removing the old axis remapping (lx=-y, ly=x, rx=-yaw).
  • The zero-velocity halt on disconnect now uses the same _publish_move(0, 0, 0) path instead of a separate WIRELESS_CONTROLLER publish.

Confidence Score: 4/5

The core change is well-scoped and the payload format matches the Unitree SPORT Move contract; the main thing to keep in mind is that the client-side auto-stop timer no longer sends an explicit zero-velocity command, relying entirely on the firmware watchdog.

The payload construction (JSON-stringified parameter, monotonic id, correct topic and msg_type) looks correct. One stale docstring and the implicit firmware-watchdog dependency for auto-stop are worth addressing but don't represent a functional regression in the command path itself.

dimos/robot/unitree/connection.py — specifically stop_movement() and the balance_stand() docstring.

Important Files Changed

Filename Overview
dimos/robot/unitree/connection.py Replaces raw WIRELESS_CONTROLLER joystick deflections with calibrated SPORT Move (api_id 1008) commands; introduces _publish_move helper and a monotonic sequence counter. The payload format and topic look correct; minor stale docstring and an implicit stop-on-command-loss assumption for the auto-stop timer.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant UnitreeWebRTCConnection
    participant EventLoop
    participant Firmware as Go2 Firmware

    Caller->>UnitreeWebRTCConnection: "move(twist, duration=0)"
    UnitreeWebRTCConnection->>UnitreeWebRTCConnection: extract x, y, yaw from twist
    UnitreeWebRTCConnection->>UnitreeWebRTCConnection: reset cmd_vel_timeout timer (0.2s)
    UnitreeWebRTCConnection->>EventLoop: run_coroutine_threadsafe(async_move)
    EventLoop->>UnitreeWebRTCConnection: _publish_move(x, y, yaw)
    UnitreeWebRTCConnection->>UnitreeWebRTCConnection: "_move_seq += 1"
    UnitreeWebRTCConnection->>Firmware: "SPORT_MOD publish_without_callback api_id=1008, parameter={x,y,z}"
    UnitreeWebRTCConnection-->>Caller: True

    Note over UnitreeWebRTCConnection,Firmware: After 0.2s of no new move() calls...
    UnitreeWebRTCConnection->>UnitreeWebRTCConnection: stop_movement() cancels timer only
    Note over Firmware: Robot stops via firmware watchdog
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant UnitreeWebRTCConnection
    participant EventLoop
    participant Firmware as Go2 Firmware

    Caller->>UnitreeWebRTCConnection: "move(twist, duration=0)"
    UnitreeWebRTCConnection->>UnitreeWebRTCConnection: extract x, y, yaw from twist
    UnitreeWebRTCConnection->>UnitreeWebRTCConnection: reset cmd_vel_timeout timer (0.2s)
    UnitreeWebRTCConnection->>EventLoop: run_coroutine_threadsafe(async_move)
    EventLoop->>UnitreeWebRTCConnection: _publish_move(x, y, yaw)
    UnitreeWebRTCConnection->>UnitreeWebRTCConnection: "_move_seq += 1"
    UnitreeWebRTCConnection->>Firmware: "SPORT_MOD publish_without_callback api_id=1008, parameter={x,y,z}"
    UnitreeWebRTCConnection-->>Caller: True

    Note over UnitreeWebRTCConnection,Firmware: After 0.2s of no new move() calls...
    UnitreeWebRTCConnection->>UnitreeWebRTCConnection: stop_movement() cancels timer only
    Note over Firmware: Robot stops via firmware watchdog
Loading

Comments Outside Diff (2)

  1. dimos/robot/unitree/connection.py, line 320-321 (link)

    P2 The balance_stand() docstring still references WIRELESS_CONTROLLER, but the codebase no longer uses that topic for movement — it now sends SPORT Move commands. A developer reading this comment after calling balance_stand() before issuing move() commands will be misled about how the robot is controlled.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

  2. dimos/robot/unitree/connection.py, line 431-435 (link)

    P2 stop_movement() doesn't send a zero-velocity command

    With the old WIRELESS_CONTROLLER transport the robot stopped naturally when the stream of joystick values ceased. The new SPORT Move API is fire-and-forget: if the firmware persists the last commanded velocity (or if its own watchdog timeout is longer than the 0.2 s client-side timer), calling stop_movement() only cancels the Python timer but leaves the robot at whatever velocity it last received. Sending self._publish_move(0.0, 0.0, 0.0) here (or scheduling it on the event loop) would make the client-side stop deterministic and not reliant on firmware watchdog behaviour alone.

Reviews (1): Last reviewed commit: "[autofix.ci] apply automated fixes" | Re-trigger Greptile

@codecov

codecov Bot commented Jun 23, 2026

Copy link
Copy Markdown

❌ 2 Tests Failed:

Tests completed Failed Passed Skipped
2275 2 2273 74
View the full list of 2 ❄️ flaky test(s)
dimos.e2e_tests.test_dimsim_spatial_memory::test_go_to_the_bed

Flake rate in main: 22.22% (Passed 28 times, Failed 8 times)

Stack Traces | 571s run time
lcm_spy = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x75cbc9bc7dd0>
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x75cbc7530220>
human_input = <function human_input.<locals>.send_human_input at 0x75cbc75307c0>
dim_sim = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x75cbc8a391c0>
explore_house = <function explore_house.<locals>.explore at 0x75cbc75311c0>

    @pytest.mark.self_hosted_large
    def test_go_to_the_bed(lcm_spy, start_blueprint, human_input, dim_sim, explore_house) -> None:
        start_blueprint(
            "run",
            "unitree-go2-agentic",
            simulator="dimsim",
        )
        lcm_spy.save_topic(".../McpClient/on_system_modules/res")
        lcm_spy.wait_for_saved_topic(".../McpClient/on_system_modules/res", timeout=1200.0)
    
        explore_house()
    
        human_input("go to the bed")
    
>       lcm_spy.wait_until_odom_position(-3.567, -1.332, threshold=2, timeout=180)

dim_sim    = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x75cbc8a391c0>
explore_house = <function explore_house.<locals>.explore at 0x75cbc75311c0>
human_input = <function human_input.<locals>.send_human_input at 0x75cbc75307c0>
lcm_spy    = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x75cbc9bc7dd0>
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x75cbc7530220>

dimos/e2e_tests/test_dimsim_spatial_memory.py:32: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
dimos/e2e_tests/lcm_spy.py:182: in wait_until_odom_position
    self.wait_for_message_result(
        predicate  = <function LcmSpy.wait_until_odom_position.<locals>.predicate at 0x75ce3c9f98a0>
        self       = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x75cbc9bc7dd0>
        threshold  = 2
        timeout    = 180
        x          = -3.567
        y          = -1.332
dimos/e2e_tests/lcm_spy.py:168: in wait_for_message_result
    self.wait_until(
        event      = <threading.Event at 0x75cbc8a3a990: unset>
        fail_message = 'Failed to get to position x=-3.567, y=-1.332'
        listener   = <function LcmSpy.wait_for_message_result.<locals>.listener at 0x75cbc7531300>
        predicate  = <function LcmSpy.wait_until_odom_position.<locals>.predicate at 0x75ce3c9f98a0>
        self       = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x75cbc9bc7dd0>
        timeout    = 180
        topic      = '/odom#geometry_msgs.PoseStamped'
        type       = <class 'dimos.msgs.geometry_msgs.PoseStamped.PoseStamped'>
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x75cbc9bc7dd0>

    def wait_until(
        self,
        *,
        condition: Callable[[], bool],
        timeout: float,
        error_message: str,
        poll_interval: float = 0.1,
    ) -> None:
        start_time = time.time()
        while time.time() - start_time < timeout:
            if condition():
                return
            time.sleep(poll_interval)
>       raise TimeoutError(error_message)
E       TimeoutError: Failed to get to position x=-3.567, y=-1.332

condition  = <bound method Event.is_set of <threading.Event at 0x75cbc8a3a990: unset>>
error_message = 'Failed to get to position x=-3.567, y=-1.332'
poll_interval = 0.1
self       = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x75cbc9bc7dd0>
start_time = 1782232964.9708984
timeout    = 180

dimos/e2e_tests/lcm_spy.py:105: TimeoutError
dimos.e2e_tests.test_dimsim_walk_forward::test_walk_forward

Flake rate in main: 28.12% (Passed 23 times, Failed 9 times)

Stack Traces | 205s run time
lcm_spy = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x75cbc8a3bc50>
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x75cbc7531b20>
human_input = <function human_input.<locals>.send_human_input at 0x75cbc7531c60>
dim_sim = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x75cbc8a48260>

    @pytest.mark.self_hosted_large
    def test_walk_forward(lcm_spy, start_blueprint, human_input, dim_sim) -> None:
        start_blueprint(
            "run",
            "--disable",
            "spatial-memory",
            "--disable",
            "security-module",
            "unitree-go2-agentic",
            simulator="dimsim",
        )
        lcm_spy.save_topic(".../McpClient/on_system_modules/res")
        lcm_spy.wait_for_saved_topic(".../McpClient/on_system_modules/res", timeout=1200.0)
    
        origin_x, origin_y = 1, 2
        dim_sim.set_agent_position(origin_x, origin_y)
    
        human_input("move forward 3 meter")
    
>       lcm_spy.wait_until_odom_position(origin_x + 3, origin_y, threshold=0.4, timeout=120)

dim_sim    = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x75cbc8a48260>
human_input = <function human_input.<locals>.send_human_input at 0x75cbc7531c60>
lcm_spy    = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x75cbc8a3bc50>
origin_x   = 1
origin_y   = 2
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x75cbc7531b20>

dimos/e2e_tests/test_dimsim_walk_forward.py:37: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
dimos/e2e_tests/lcm_spy.py:182: in wait_until_odom_position
    self.wait_for_message_result(
        predicate  = <function LcmSpy.wait_until_odom_position.<locals>.predicate at 0x75cbc7531760>
        self       = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x75cbc8a3bc50>
        threshold  = 0.4
        timeout    = 120
        x          = 4
        y          = 2
dimos/e2e_tests/lcm_spy.py:168: in wait_for_message_result
    self.wait_until(
        event      = <threading.Event at 0x75cbc8a48890: unset>
        fail_message = 'Failed to get to position x=4, y=2'
        listener   = <function LcmSpy.wait_for_message_result.<locals>.listener at 0x75cbc7530360>
        predicate  = <function LcmSpy.wait_until_odom_position.<locals>.predicate at 0x75cbc7531760>
        self       = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x75cbc8a3bc50>
        timeout    = 120
        topic      = '/odom#geometry_msgs.PoseStamped'
        type       = <class 'dimos.msgs.geometry_msgs.PoseStamped.PoseStamped'>
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x75cbc8a3bc50>

    def wait_until(
        self,
        *,
        condition: Callable[[], bool],
        timeout: float,
        error_message: str,
        poll_interval: float = 0.1,
    ) -> None:
        start_time = time.time()
        while time.time() - start_time < timeout:
            if condition():
                return
            time.sleep(poll_interval)
>       raise TimeoutError(error_message)
E       TimeoutError: Failed to get to position x=4, y=2

condition  = <bound method Event.is_set of <threading.Event at 0x75cbc8a48890: unset>>
error_message = 'Failed to get to position x=4, y=2'
poll_interval = 0.1
self       = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x75cbc8a3bc50>
start_time = 1782233230.2421224
timeout    = 120

dimos/e2e_tests/lcm_spy.py:105: TimeoutError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Jun 23, 2026
Comment thread dimos/robot/unitree/connection.py
@spomichter spomichter merged commit ca7637e into main Jun 24, 2026
41 of 44 checks passed
@spomichter spomichter deleted the mustafa/go2-velocity-move branch June 24, 2026 02:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-merge Required CI checks have passed on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants